package edu.washington.cs.sounddetector.server.api.methodlogic;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import javax.jdo.PersistenceManager;
import javax.jdo.PersistenceManagerFactory;
import javax.jdo.Transaction;
import javax.sound.sampled.AudioFormat;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.scheduling.annotation.Async;

import weka.classifiers.Classifier;
import weka.classifiers.meta.AdaBoostM1;
import weka.core.FastVector;
import weka.core.Instance;
import weka.core.Instances;
import edu.washington.cs.sounddetector.server.api.APIConsts;
import edu.washington.cs.sounddetector.server.api.APIConsts.PushNotificationType;
import edu.washington.cs.sounddetector.server.api.exception.SoundDetectorInternalErrorException;
import edu.washington.cs.sounddetector.server.api.method.Classify;
import edu.washington.cs.sounddetector.server.api.method.GetPendingNotifications;
import edu.washington.cs.sounddetector.server.api.method.RegisterReceiverPhone;
import edu.washington.cs.sounddetector.server.api.method.RenameDetector;
import edu.washington.cs.sounddetector.server.api.method.SetSoundClassProperties;
import edu.washington.cs.sounddetector.server.api.method.Train;
import edu.washington.cs.sounddetector.server.api.methodresult.ClassifyResult;
import edu.washington.cs.sounddetector.server.api.methodresult.GetPendingNotificationsResult;
import edu.washington.cs.sounddetector.server.api.methodresult.RegisterReceiverPhoneResult;
import edu.washington.cs.sounddetector.server.api.methodresult.RenameDetectorResult;
import edu.washington.cs.sounddetector.server.api.methodresult.SetSoundClassPropertiesResult;
import edu.washington.cs.sounddetector.server.api.methodresult.TrainResult;
import edu.washington.cs.sounddetector.server.api.object.LabeledSound;
import edu.washington.cs.sounddetector.server.api.object.Notification;
import edu.washington.cs.sounddetector.server.api.object.PeakEvent;
import edu.washington.cs.sounddetector.server.api.object.SoundClass;
import edu.washington.cs.sounddetector.server.api.object.SoundFormat;
import edu.washington.cs.sounddetector.server.c2dm.C2DMessaging;
import edu.washington.cs.sounddetector.server.database.DbSoundClass;
import edu.washington.cs.sounddetector.server.database.Model;
import edu.washington.cs.sounddetector.server.database.Notifications;
import edu.washington.cs.sounddetector.server.database.ReceiverPhone;
import edu.washington.cs.sounddetector.server.database.Sound;
import edu.washington.cs.sounddetector.server.exception.NoFeaturesExtractedException;
import edu.washington.cs.sounddetector.server.sound.SoundFeatures;
import edu.washington.cs.sounddetector.server.sound.SoundHelper;
import edu.washington.cs.sounddetector.server.sound.SoundProcessor;
import edu.washington.cs.sounddetector.server.sound.SoundProcessorBuilder;
import edu.washington.cs.sounddetector.server.weka.WekaConsts;
import edu.washington.cs.sounddetector.server.weka.WekaHelper;

/**
 * Implements logic that performs write operations on the database.
 * 
 * @author the.dylan.price@gmail.com
 * 
 */
public class WriteLogic
{

    private static final Log log = LogFactory.getLog(WriteLogic.class);

    private PersistenceManagerFactory pmf;
    private SoundProcessorBuilder spb;
    private C2DMessaging c2dm;

    public WriteLogic(PersistenceManagerFactory pmf, SoundProcessorBuilder spb, C2DMessaging c2dm)
    {
        this.pmf = pmf;
        this.spb = spb;
        this.c2dm = c2dm;
    }

    public RegisterReceiverPhoneResult registerReceiverPhone(
            RegisterReceiverPhone registerReceiverPhone)
    {
        String phoneId = registerReceiverPhone.getPhoneId();
        String registrationId = registerReceiverPhone.getRegistrationId();

        PersistenceManager pm = pmf.getPersistenceManager();

        // TODO: change when we implement users
        ReceiverPhone phone = ReceiverPhone.query(pm, "1", phoneId);

        Transaction tx = pm.currentTransaction();
        try
        {
            tx.begin();
            if (phone == null)
            {
                phone = new ReceiverPhone(phoneId, registrationId);
            }
            else
            {
                phone.setRegistrationId(registrationId);
            }
            pm.makePersistent(phone);
            tx.commit();

            return RegisterReceiverPhoneResult.success();
        }
        finally
        {
            if (tx.isActive())
            {
                tx.rollback();
            }
            pm.close();
        }
    }

    public TrainResult train(Train train) throws SoundDetectorInternalErrorException
    {
        String detectorId = train.getDetectorId();
        List<LabeledSound> data = train.getData();
        SoundFormat soundFormat = train.getSoundFormat();
        AudioFormat format = SoundHelper.convert(soundFormat);

        PersistenceManager pm = pmf.getPersistenceManager();
        Transaction tx = pm.currentTransaction();
        try
        {
            tx.begin();

            // create classes
            // TODO: change when we implement users
            DbSoundClass.createUnknownClass(pm, "1", detectorId, spb);
            Collection<String> soundClassNames = getSoundClassNames(data);
            Collection<DbSoundClass> soundClasses = persistSoundClasses(pm, soundClassNames,
                    detectorId);

            // get sounds
            SoundProcessor processor = spb.build();
            String[] featureNames = null;
            Collection<Sound> sounds = new ArrayList<Sound>();
            for (LabeledSound labeledSound : data)
            {
                double[] samples = processor.sample(labeledSound.getSound(), format);
                SoundFeatures features = processor.extractFeatures(samples);
                if (featureNames == null)
                    featureNames = features.getFlattenedFeatureNames();
                DbSoundClass soundClass = findSoundClass(soundClasses, labeledSound.getSoundClass());
                Sound sound = new Sound(soundClass, labeledSound.getSound(), samples,
                        features.getFlattenedFeatureValues());
                sounds.add(sound);
            }
            pm.makePersistentAll(sounds);

            List<Long> soundIds = new ArrayList<Long>();
            for (Sound sound : sounds)
            {
                soundIds.add(sound.getId());
            }

            // train model
            Model model = retrieveOrCreateModel(pm, detectorId, featureNames);
            WekaHelper.trainModel(pm, model);

            tx.commit();
            return TrainResult.success(soundIds);
        }
        catch (NoFeaturesExtractedException e)
        {
            throw new SoundDetectorInternalErrorException(
                    "NoFeaturesExtractedException yet SoundProcessor.extractFeatures() has been called.",
                    e);
        }
        catch (Exception e)
        {
            log.warn("Could not build classifier", e);
            return TrainResult.failureCantBuildClassifier(e.getMessage());
        }
        finally
        {
            if (tx.isActive())
            {
                tx.rollback();
            }
            pm.close();
        }
    }

    private Collection<String> getSoundClassNames(Collection<LabeledSound> labeledSounds)
    {
        List<String> soundClasses = new ArrayList<String>();
        for (LabeledSound labeledSound : labeledSounds)
        {
            soundClasses.add(labeledSound.getSoundClass());
        }
        return soundClasses;
    }

    /**
     * Checks whether or not the given class is in the database, and creates and persists a new
     * {@link DbSoundClass} if it is not.
     * 
     * @param pm
     * @param soundClass
     * @param detectorId
     */
    private Collection<DbSoundClass> persistSoundClasses(PersistenceManager pm,
            Collection<String> sounds, String detectorId)
    {
        // TODO: change when we implement users
        Collection<DbSoundClass> soundClasses = new ArrayList<DbSoundClass>(DbSoundClass.query(pm,
                "1", detectorId));

        Collection<String> soundClassNames = DbSoundClass.extractSoundClassNames(soundClasses);

        Set<String> newSoundClasses = new HashSet<String>();
        for (String soundClass : sounds)
        {
            if (!soundClassNames.contains(soundClass))
            {
                newSoundClasses.add(soundClass);
            }
        }
        for (String soundClass : newSoundClasses)
        {
            DbSoundClass newClass = new DbSoundClass(soundClass, detectorId);
            soundClasses.add(newClass);
            pm.makePersistent(newClass);
        }
        return soundClasses;
    }

    private DbSoundClass findSoundClass(Collection<DbSoundClass> soundClasses, String soundClass)
    {
        DbSoundClass sc = null;
        for (DbSoundClass dbsc : soundClasses)
        {
            if (dbsc.getSoundClass().equals(soundClass))
                sc = dbsc;
        }
        return sc;
    }

    private Model retrieveOrCreateModel(PersistenceManager pm, String detectorId,
            String[] featureNames)
    {
        // TODO: change when we implement users
        Model model = Model.query(pm, "1", detectorId);
        List<String> attributes;
        Classifier classifier;
        if (model != null)
        {
            attributes = model.getAttributes();
            classifier = model.getClassifier();
        }
        else
        {
            attributes = Arrays.asList(featureNames);
            classifier = new AdaBoostM1();
            model = new Model(detectorId, attributes, classifier);
        }
        pm.makePersistent(model);
        return model;
    }

    public ClassifyResult classify(Classify classify) throws SoundDetectorInternalErrorException
    {
        String detectorId = classify.getDetectorId();
        byte[] sound = classify.getSound();
        List<PeakEvent> peakEvents = classify.getPeakEvents();
        SoundFormat soundFormat = classify.getSoundFormat();
        AudioFormat format = SoundHelper.convert(soundFormat);

        PersistenceManager pm = this.pmf.getPersistenceManager();
        Transaction tx = pm.currentTransaction();
        String soundClass;
        try
        {
            tx.begin();

            // TODO: change when we implement users
            Model model = Model.query(pm, "1", detectorId);
            if (model == null)
                return ClassifyResult.failureDetectorIdDoesNotExist(detectorId);
            List<String> attributes = model.getAttributes();
            Classifier classifier = model.getClassifier();

            SoundProcessor processor = this.spb.build();
            double[] samples = processor.sample(sound, format);
            double[] features;
            try
            {
                SoundFeatures soundFeatures = processor.extractFeatures(samples);
                features = soundFeatures.getFlattenedFeatureValues();
            }
            catch (Exception e)
            {
                throw new SoundDetectorInternalErrorException(e);
            }

            Instance instance = new Instance(1.0, features);

            // TODO: change when we implement users
            Collection<DbSoundClass> soundClasses = DbSoundClass.query(pm, "1", detectorId);
            Collection<String> soundClassNames = DbSoundClass.extractSoundClassNames(soundClasses);
            FastVector attrInfo = WekaHelper.createAttributesVector(soundClassNames, attributes);
            Instances instances = new Instances(WekaConsts.RELATION_NAME, attrInfo, 1);
            instances.setClassIndex(0);

            instance.insertAttributeAt(0);
            instance.setDataset(instances);

            try
            {
                int soundClassIndex = (int) classifier.classifyInstance(instance);
                soundClass = instances.classAttribute().value(soundClassIndex);
            }
            catch (Exception e)
            {
                throw new SoundDetectorInternalErrorException(e);
            }

            addPendingNotifications("1", soundClass, detectorId, peakEvents);

            tx.commit();
            return ClassifyResult.success(soundClass);
        }
        finally
        {
            if (tx.isActive())
            {
                tx.rollback();
            }
            pm.close();
        }
    }

    @Async
    private void addPendingNotifications(String userId, String soundClass, String detectorId,
            List<PeakEvent> peakEvents)
    {
        PersistenceManager pm = pmf.getPersistenceManager();
        Transaction tx = pm.currentTransaction();
        try
        {
            tx.begin();

            DbSoundClass sc = DbSoundClass.query(pm, userId, soundClass, detectorId);
            // TODO: change when we implement users
            Collection<ReceiverPhone> receiverPhones = ReceiverPhone.query(pm, "1");
            for (ReceiverPhone phone : receiverPhones)
            {
                Notifications pn = new Notifications(sc, phone, peakEvents);
                pm.makePersistent(pn);
            }

            tx.commit();
            for (ReceiverPhone phone : receiverPhones)
            {
                String registrationId = phone.getRegistrationId();
                Map<String, String> pushData = new HashMap<String, String>();
                pushData.put(APIConsts.PUSH_KEY_NOTIFICATION_TYPE,
                        PushNotificationType.SOUND_DETECTED.name());
                if (registrationId != null)
                    c2dm.sendPush(registrationId, pushData);
            }
        }
        finally
        {
            if (tx.isActive())
                tx.rollback();
            pm.close();
        }
    }

    public GetPendingNotificationsResult getPendingNotifications(
            GetPendingNotifications getPendingNotifications)
    {
        String phoneId = getPendingNotifications.getPhoneId();
        PersistenceManager pm = pmf.getPersistenceManager();
        Transaction tx = pm.currentTransaction();
        try
        {
            tx.begin();
            // TODO: change when we implement users
            ReceiverPhone receiverPhone = ReceiverPhone.query(pm, "1", phoneId);
            if (receiverPhone == null)
                return GetPendingNotificationsResult.failurePhoneDoesNotExist(phoneId);
            // TODO: change when we implement users
            Collection<Notifications> pendingNotifications = Notifications.query(pm, "1",
                    receiverPhone, false);
            List<Notification> notifications = new ArrayList<Notification>(
                    pendingNotifications.size());
            for (Notifications pn : pendingNotifications)
            {
                SoundClass soundClass = new SoundClass(pn.getSoundClass().getSoundClass(), pn
                        .getSoundClass().getRgbColor());
                Notification notification = new Notification(soundClass, pn.getSoundClass()
                        .getDetectorId(), pn.getPeakEvents());
                notifications.add(notification);
                pn.setRead(true);
            }
            pm.makePersistentAll(pendingNotifications);
            tx.commit();
            return GetPendingNotificationsResult.success(notifications);
        }
        finally
        {
            if (tx.isActive())
                tx.rollback();
            pm.close();
        }
    }

    public SetSoundClassPropertiesResult setSoundClassProperties(SetSoundClassProperties method)
    {
        String detectorId = method.getDetectorId();
        List<SoundClass> soundClasses = method.getSoundClasses();
        List<String> soundClassNames = new ArrayList<String>();
        for (SoundClass soundClass : soundClasses)
            soundClassNames.add(soundClass.getSoundClass());
        PersistenceManager pm = pmf.getPersistenceManager();
        Transaction tx = pm.currentTransaction();
        try
        {
            tx.begin();
            // TODO: change when we implement users
            Collection<DbSoundClass> dbSoundClasses = DbSoundClass.query(pm, "1", detectorId);
            if (dbSoundClasses == null || dbSoundClasses.isEmpty())
                return SetSoundClassPropertiesResult.failureDetectorIdDoesNotExist(detectorId);
            for (SoundClass soundClass : soundClasses)
            {
                boolean found = false;
                for (DbSoundClass dbSoundClass : dbSoundClasses)
                {
                    if (dbSoundClass.getSoundClass().equals(soundClass.getSoundClass()))
                    {
                        dbSoundClass.setRgbColor(soundClass.getRgbColor());
                        pm.makePersistent(dbSoundClass);
                        found = true;
                        break;
                    }
                }
                if (found == false)
                    return SetSoundClassPropertiesResult.failureSoundClassDoesNotExist(soundClass
                            .getSoundClass());
            }
            tx.commit();
            return SetSoundClassPropertiesResult.success();
        }
        finally
        {
            if (tx.isActive())
                tx.rollback();
            pm.close();
        }

    }

    public RenameDetectorResult renameDetector(RenameDetector method)
    {
        String oldDetectorId = method.getOldDetectorId();
        String newDetectorId = method.getNewDetectorId();
        PersistenceManager pm = pmf.getPersistenceManager();
        Transaction tx = pm.currentTransaction();
        try
        {
            tx.begin();
            // TODO: change when we implement users
            Collection<DbSoundClass> soundClasses = DbSoundClass.query(pm, "1", oldDetectorId);
            Model model = Model.query(pm, "1", oldDetectorId);

            if (soundClasses == null || soundClasses.isEmpty() || model == null)
                return RenameDetectorResult.failureDetectorIdDoesNotExist(oldDetectorId);

            for (DbSoundClass soundClass : soundClasses)
            {
                soundClass.setDetectorId(newDetectorId);
                pm.makePersistent(soundClass);
            }
            model.setDetectorId(newDetectorId);
            pm.makePersistent(model);

            tx.commit();
            return RenameDetectorResult.success();
        }
        finally
        {
            if (tx.isActive())
                tx.rollback();
            pm.close();
        }
    }

}
